疯狂的Java算法——插入排序,归并排序以及并行归并排序
从古至今的难题
在IT届有一道百算不厌其烦的题,俗称排序。不管是你参加BAT等高端笔试,亦或是藏匿于街头小巷的草根笔试,都会经常见到这样一道百年难得一解的问题。
今天LZ有幸与各位分享一下算法届的草根明星,排序届的领衔大神——插入排序以及归并排序。最后,在头脑风暴下,LZ又有幸认识了一位新朋友,名叫并行归并排序。接下来,咱们就一一认识一下,并且在最后来一次“算林大会”吧。
插入排序简介
插入排序,算林称最亲民的排序算法,插入排序采用最简单的插入方式对一个整数数组进行排序。它循环数组中从第二个开始的所有元素,并且将每一个循环到的元素插入到相应的位置,从而实现排序的目的。
插入排序的代码展示
使用Java代码描述插入排序,可以用以下的代码。
package algorithm;
/**
* @author zuoxiaolong
*
*/
public abstract class InsertSort {
public static void sort(int[] numbers){
for (int i = 1; i < numbers.length; i++) {
int currentNumber = numbers[i];
int j = i - 1;
while (j >= 0 && numbers[j] > currentNumber) {
numbers[j + 1] = numbers[j];
j--;
}
numbers[j + 1] = currentNumber;
}
}
}
这个算法从数组的第二个元素开始循环,将选中的元素与之前的元素一一比较,如果选中的元素小于之前的元素,则将之前的元素后移,最后再将选中的元素放在合适的位置。在这个算法执行的过程中,总是保持着索引i之前的数组是升序排列的。
插入排序理解起来比较简单,因此LZ就不过多的解释它的实现原理了,尚未理解的猿友可以自行研究。
插入排序的性能分析
接下来,咱们来简单分析一下插入排序的性能。首先,插入排序当中有两个循环,假设数组的大小为n,则第一个循环是n-1次,第二个while循环在最坏的情况下是1到n-1次。因此插入排序的时间复杂度大约为如下形式。
1+2+3+4+...+n-1 = n(n-1)/ 2 = O(n2)
时间复杂度为输入规模的2次函数,可见插入排序的时间复杂度是比较高的。这是原理上的简单分析,最后在“算林大会”中,各位可以清楚的看到插入排序随着输入规模的增长,时间会指数倍的上升。
归并排序简介
归并排序,算林届的新秀,引领着分治法的潮流。归并排序将排序问题拆分,比如分成两个较小的数组,然后对拆分后的数组分别进行排序,最后再将排序后的较小数组进行合并。
这种思想是一种算法设计的思想,很多问题都可以采用这种方式解决。映射到编程领域,其实就是递归的思想。因此在归并排序的算法中,将会出现递归调用。
归并排序的代码展示
归并排序主要由两个方法组成,一个是用于合并两个已经排序的数组的方法,一个则是递归方法,用于将问题无限拆分。接下来咱们一起看看归并排序的Java代码展示,如下所示。
package algorithm;
/**
* @author zuoxiaolong
*
*/
public abstract class MergeSort {
public static void sort(int[] numbers){
sort(numbers, 0, numbers.length);
}
public static void sort(int[] numbers,int pos,int end){
if ((end - pos) > 1) {
int offset = (end + pos) / 2;
sort(numbers, pos, offset);
sort(numbers, offset, end);
merge(numbers, pos, offset, end);
}
}
public static void merge(int[] numbers,int pos,int offset,int end){
int[] array1 = new int[offset - pos];
int[] array2 = new int[end - offset];
System.arraycopy(numbers, pos, array1, 0, array1.length);
System.arraycopy(numbers, offset, array2, 0, array2.length);
for (int i = pos,j=0,k=0; i < end ; i++) {
if (j == array1.length) {
System.arraycopy(array2, k, numbers, i, array2.length - k);
break;
}
if (k == array2.length) {
System.arraycopy(array1, j, numbers, i, array1.length - j);
break;
}
if (array1[j] <= array2[k]) {
numbers[i] = array1[j++];
} else {
numbers[i] = array2[k++];
}
}
}
}
可以看到,归并排序将一个长度为n的数组平均分为两个n/2的数组分别进行处理,因此,在sort方法中又调用了两次sort方法自身。当数组大小为1时,则认为该数组为已经为排好序的数组。因此在sort方法中,需要end与pos相差大于2时,才需要进一步拆分,这也是递归的终止条件。
此外,在代码中,使用了Java提供的arraycory函数进行数组复制,这种直接复制内存区域的方式,将会比循环赋值的方式速度更快。有些算法实现会给merge方法中的两个临时数组设置哨兵,目的是为了防止merge中for循环的前两个if判断。为了方便理解,LZ这里没有设置哨兵,当某一个数组的元素消耗完时,将直接使用arraycopy方法把另外一个数组copy到numbers当中。
归并排序的性能分析
与插入排序一样,咱们来简单分析一下归并排序的时间复杂度。咱们假设数组的大小为n,sort方法的时间复杂度为f(end-pos)。简单的分析merge方法的复杂度,不难发现为(end-pos)*2,这个结果的前提是咱们认为arraycopy方法的复杂度为length参数。
基于以上的假设,由于end-pos的初始值为n,因此归并排序的复杂度大约为如下形式。
2*f(n/2) + 2*n = 2*(2*f(n/4)+2*(n/2)) + 2*n=4*f(n/4) + 2*n + 2*n = n *f(1) + 2*n +...+2*n
其中f(1)的时间复杂度为常量,假设f(1)=c,而2*n将有log2n个。因此咱们得到归并排序的最终时间复杂度为如下形式。
cn + 2n*log2n = O(n*log2n)
归并排序的时间复杂度与插入排序相比,已经降低了很多,这一点在数组的输入规模较大时将会非常明显,因为log函数的增加速度将远远低于n的增加速度。
并行归并排序简介
并行归并排序是LZ在学习归并排序时意淫出来的,最近LZ正在研究Java的并发编程,恰好归并排序的子问题有一定的并行度与独立性,因此LZ版的并发归并排序就这样诞生了。事后,LZ也人肉过并行归并排序这个家伙,发现早已众所周知,不过在不知道的情况下自己能够想到是不是也应该窃喜一下呢。
并行归并排序与普通的归并排序没有多大区别,只是利用现在计算机多核的优势,在有可能的情况下,让两个或多个子问题的处理一起进行。这样一来,在效率上,并行归并排序将会比归并排序更胜一筹。
并行归并排序的代码展示
并行归并排序主要对sort方法进行了修改,基础的merge方法与普通的归并排序是一样的。因此在进行并行归并排序时,引用了归并排序的一些方法,具体的代码如下所示。
package algorithm;
import java.util.concurrent.CountDownLatch;
/**
* @author zuoxiaolong
*
*/
public abstract class MergeParallelSort {
private static final int maxAsynDepth = (int)(Math.log(Runtime.getRuntime().availableProcessors())/Math.log(2));
public static void sort(int[] numbers) {
sort(numbers, maxAsynDepth);
}
public static void sort(int[] numbers,Integer asynDepth) {
sortParallel(numbers, 0, numbers.length, asynDepth > maxAsynDepth ? maxAsynDepth : asynDepth, 1);
}
public static void sortParallel(final int[] numbers,final int pos,final int end,final int asynDepth,final int depth){
if ((end - pos) > 1) {
final CountDownLatch mergeSignal = new CountDownLatch(2);
final int offset = (end + pos) / 2;
Thread thread1 = new SortThread(depth, asynDepth, numbers, mergeSignal, pos, offset);
Thread thread2 = new SortThread(depth, asynDepth, numbers, mergeSignal, offset, end);
thread1.start();
thread2.start();
try {
mergeSignal.await();
} catch (InterruptedException e) {}
MergeSort.merge(numbers, pos, offset, end);
}
}
static class SortThread extends Thread {
private int depth;
private int asynDepth;
private int[] numbers;
private CountDownLatch mergeSignal;
private int pos;
private int end;
/**
* @param depth
* @param asynDepth
* @param numbers
* @param mergeSignal
* @param pos
* @param end
*/
public SortThread(int depth, int asynDepth, int[] numbers, CountDownLatch mergeSignal, int pos, int end) {
super();
this.depth = depth;
this.asynDepth = asynDepth;
this.numbers = numbers;
this.mergeSignal = mergeSignal;
this.pos = pos;
this.end = end;
}
@Override
public void run() {
if (depth < asynDepth) {
sortParallel(numbers,pos,end,asynDepth,(depth + 1));
} else {
MergeSort.sort(numbers, pos, end);
}
mergeSignal.countDown();
}
}
}
在这段代码中,有几点是比较特殊的,LZ简单的说明一下。
1,分解后的问题采用了并行的方式处理,并且咱们设定了一个参数asynDepth去控制并行的深度,通常情况下,深度为(log2CPU核数)即可。
2,当子问题不进行并行处理时,并行归并排序调用了普通归并排序的方法,比如MergeSort.sort和MergeSort.merge。
3,因为合并操作依赖于两个子问题的完成,因此咱们设定了一个合并信号(mergeSignal),当信号发出时,才进行合并操作。
并行归并排序在原理上与普通的归并排序是一样的,只是对于子问题的处理采用了一定程度上的并行,因此如果猿友们理解归并排序,那么并行归并排序并不难理解。
并行归并排序的性能分析
并行归并排序只是将普通归并排序中一些可并行的操作进行了并行处理,因此在总体的时间复杂度上并没有质的变化,都是O(n*log2n)。
由于并行归并排序将某些排序操作并行操作,因此在性能上一定是快于普通归并排序算法的。不过这也不是一定的,当数组规模太小时,并行带来的性能提高可能会小于线程创建和销毁的开销,此时并行归并排序的性能可能会低于普通归并排序。
算林大会
接下来,就是一周一度的算林大会了,本次算林大会主要由以上三种算法参加,胜者将会成为本周度最受欢迎算法。接下来是算林大会的代码,请各位猿友过目。
package algorithm;
import java.io.File;
import java.lang.reflect.Method;
import java.util.Random;
/**
* @author zuoxiaolong
*
*/
public class SortTests {
public static void main(String[] args) {
testAllSortIsCorrect();
testComputeTime("MergeParallelSort", 40000, 5);
testComputeTime("MergeSort", 40000, 5);
testComputeTime("InsertSort", 400, 5);
}
public static void testAllSortIsCorrect() {
File classpath = new File(SortTests.class.getResource("").getFile());
File[] classesFiles = classpath.listFiles();
for (int i = 0; i < classesFiles.length; i++) {
if (classesFiles[i].getName().endsWith("Sort.class")) {
System.out.println("---测试" + classesFiles[i].getName() + "是否有效---");
testSortIsCorrect(classesFiles[i].getName().split("\\.")[0]);
}
}
}
public static void testSortIsCorrect(String className){
for (int i = 1; i < 50; i++) {
int[] numbers = getRandomIntegerArray(1000 * i);
invoke(numbers, className);
for (int j = 1; j < numbers.length; j++) {
if (numbers[j] < numbers[j-1]) {
throw new RuntimeException(className + " sort is error because " + numbers[j] + "<" + numbers[j-1]);
}
}
}
System.out.println("---" + className + "经测试有效---");
}
public static void testComputeTime(String className,int initNumber,int times,Object... arguments) {
long[] timeArray = new long[times];
for (int i = initNumber,j = 0; j < times; i = i * 10,j++) {
timeArray[j] = computeTime(i, className, arguments);
}
System.out.print(className + "时间增加比例:");
for (int i = 1; i < timeArray.length ; i++) {
System.out.print((float)timeArray[i]/timeArray[i - 1]);
if (i < timeArray.length - 1) {
System.out.print(",");
}
}
System.out.println();
}
public static long computeTime(int length,String className,Object... arguments){
int[] numbers = getRandomIntegerArray(length);
long start = System.currentTimeMillis();
System.out.print("开始计算长度为"+numbers.length+"方法为"+className+"参数为[");
for (int i = 0; i < arguments.length; i++) {
System.out.print(arguments[i]);
if (i < arguments.length - 1) {
System.out.print(",");
}
}
System.out.print("],时间为");
invoke(numbers, className, arguments);
long time = System.currentTimeMillis()-start;
System.out.println(time + "ms");
return time;
}
public static int[] getRandomIntegerArray(int length){
int[] numbers = new int[length];
for (int i = 0; i < numbers.length; i++) {
numbers[i] = new Random().nextInt(length);
}
return numbers;
}
public static void invoke(int[] numbers,String className,Object... arguments){
try {
Class<?> clazz = Class.forName("algorithm." + className);
Class<?>[] parameterTypes = new Class<?>[arguments.length + 1];
parameterTypes[0] = int[].class;
for (int i = 0; i < arguments.length; i++) {
parameterTypes[i + 1] = arguments[i].getClass();
}
Method method = clazz.getDeclaredMethod("sort", parameterTypes);
Object[] parameters = new Object[parameterTypes.length];
parameters[0] = numbers;
for (int i = 0; i < arguments.length; i++) {
parameters[i + 1] = arguments[i];
}
method.invoke(null, parameters);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
以上代码testAllSortIsCorrect方法首先验证了三种算法的正确性,也就是说经过sort方法后,数组是否已经升序排列。需要一提的是,由于插入排序的性能太低,因此插入排序测试的最大规模为400万,而归并排序测试的最大规模为4亿。
接下来,大家就一起看看运行结果吧。以下是在LZ的mac pro上的运行结果,硬件配置为16G内存,4核i7。这种配置下,异步深度(asynDepth)默认为log24=2。
---测试InsertSort.class是否有效---
---InsertSort经测试有效---
---测试MergeParallelSort.class是否有效---
---MergeParallelSort经测试有效---
---测试MergeSort.class是否有效---
---MergeSort经测试有效---
开始计算长度为40000方法为MergeParallelSort参数为[],时间为6ms
开始计算长度为400000方法为MergeParallelSort参数为[],时间为44ms
开始计算长度为4000000方法为MergeParallelSort参数为[],时间为390ms
开始计算长度为40000000方法为MergeParallelSort参数为[],时间为3872ms
开始计算长度为400000000方法为MergeParallelSort参数为[],时间为47168ms
MergeParallelSort时间增加比例:7.3333335,8.863636,9.9282055,12.181818
开始计算长度为40000方法为MergeSort参数为[],时间为7ms
开始计算长度为400000方法为MergeSort参数为[],时间为81ms
开始计算长度为4000000方法为MergeSort参数为[],时间为839ms
开始计算长度为40000000方法为MergeSort参数为[],时间为9517ms
开始计算长度为400000000方法为MergeSort参数为[],时间为104760ms
MergeSort时间增加比例:11.571428,10.358025,11.343266,11.00767
开始计算长度为400方法为InsertSort参数为[],时间为0ms
开始计算长度为4000方法为InsertSort参数为[],时间为3ms
开始计算长度为40000方法为InsertSort参数为[],时间为245ms
开始计算长度为400000方法为InsertSort参数为[],时间为23509ms
开始计算长度为4000000方法为InsertSort参数为[],时间为3309180ms
InsertSort时间增加比例:Infinity,81.666664,95.9551,140.76227
首先可以看到,三种算法都是运行正确的。接下来,咱们可以对比一下三种算法的性能。
根据输出结果,规模为400万时的区别是最明显与直观的。并行归并排序仅需要390ms就完成了400万规模的排序,而普通的归并排序则需要839ms才可以,至于插入排序,简直是不可理喻,竟然需要300多万ms,大约50分钟。
咱们再来看三者的时间增长趋势。两种归并排序基本上与规模的增长趋势相似,每当规模增加10倍时,时间也基本上增加10倍,而插入排序则几乎是以100倍的速度在增加,刚好是数组规模增长速度的平方。其中的Infinity是因为当数组规模为400时,毫秒级别的计时为0ms,因此当除数为0时,结果就为Infinity。
当然了,这一次结果具有一定的随机性,猿友们可以在自己的电脑上多实验几次观察一下,不过插入排序的时间实在让人等的蛋疼。
小结
好了,本文就到此为止了。对于算法的学习还需要继续,以后LZ也会尽量多分享一些自己学习的过程在这里,各位猿友敬请期待吧。
本周最佳算法:并行归并排序!